Skip to content

Register command routes independently of the application's routing style - #34

Merged
stixx merged 5 commits into
mainfrom
fix/command-route-autodiscovery
Aug 30, 2026
Merged

Register command routes independently of the application's routing style#34
stixx merged 5 commits into
mainfrom
fix/command-route-autodiscovery

Conversation

@stixx

@stixx stixx commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Follow-up to #33, and independent of it — both branch from main.

Problem

Command route discovery hung off AttributeDirectoryLoaderDecorator, which decorated routing.loader.attribute.directory. Discovery therefore only ran as a side effect of the application happening to load routes through that specific loader.

The current Symfony skeleton's config/routes.yaml does not:

controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

The namespace key routes this through Psr4DirectoryLoader, not AttributeDirectoryLoader. The decorator is never invoked, no command routes are registered, and there is no error — endpoints simply 404. Working around it means adding a routing import purely to coax the right loader into running.

Change

Decorate routing.loader instead — FrameworkBundle's DelegatingLoader, which is what Router::getRouteCollection() resolves and calls exactly once for the root routing resource. Discovery now runs regardless of how the application declares its routes, or whether it declares any.

Two properties make this safe:

  • DelegatingLoader is not itself tagged routing.loader, so it is absent from the resolver that nested imports go through — the decoration cannot recurse.
  • The router calls it once per build, so there is no need for the $augmented latch the old decorator used to avoid re-scanning.

Supporting changes:

  • Discovery moves out of the decorator into CommandRouteDiscovery.
  • New command_paths setting, defaulting to ['%kernel.project_dir%/src'] — the path that was previously hardcoded. Non-existent paths are skipped; [] disables discovery entirely.
  • CommandRouteDirectoryLoader already existed and was unit-tested, but was never registered as a service. It is now the directory loader behind discovery, and registering it makes type: stixx_openapi_command.command_attributes usable from an application's routing config.
  • CommandRouteClassLoader keeps its routing.loader tag, so $routes->import(SomeCommand::class, 'attribute') still works.
  • Routes already present in the collection are left alone, so an explicitly imported command is not displaced.

Tests

The functional kernel imports every command explicitly ($routes->import(CreateBookCommand::class, 'attribute')), so it exercised the explicit-import path and never covered discovery — which is how this shipped broken.

RouteDiscoveryTest adds a kernel that declares no routes, and asserts both that the routes are registered and that they serve requests. Verified against the old decoration target: both tests fail, the request test with a 404.

216 tests pass; PHPStan and PHP-CS-Fixer are clean.

Compatibility

AttributeDirectoryLoaderDecorator is removed. It is @internal, as are all route loaders per the README's stability policy. Applications that added a routing import to work around the old behaviour can keep it — those routes win over discovered ones.

Command route discovery hung off a decorator on
`routing.loader.attribute.directory`, so it only ran when the application
happened to load routes through that loader. The current Symfony skeleton's
config/routes.yaml sets a `namespace`, which routes through Psr4DirectoryLoader
instead, so the decorator was never invoked and command routes silently did not
exist — no error, just 404s.

Decorate `routing.loader` instead. That is the loader the router resolves to
build its collection, and it is called exactly once for the root routing
resource, so discovery runs regardless of how (or whether) the application
declares its own routes. DelegatingLoader is not itself tagged `routing.loader`,
so it is absent from the resolver nested imports go through and the decoration
cannot recurse.

Discovery moves out of the decorator into CommandRouteDiscovery, which scans the
new `command_paths` setting (default `%kernel.project_dir%/src`, matching the
previous hardcoded path) and skips paths that do not exist. Setting it to []
disables discovery for applications that prefer to import commands explicitly.

CommandRouteDirectoryLoader existed and was unit-tested but was never registered
as a service; it is now the directory-scanning loader behind discovery and is
registered so `type: stixx_openapi_command.command_attributes` works from an
application's routing config.

The functional kernel imports every command explicitly, so it exercised the
explicit-import path and never covered discovery. RouteDiscoveryTest adds a
kernel that declares no routes at all; both of its tests fail against the old
decoration target.
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 21 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c51ff5bc-240b-4774-9c79-61e8cd159dc3

📥 Commits

Reviewing files that changed from the base of the PR and between e9e1295 and 354c22d.

⛔ Files ignored due to path filters (2)
  • tests/Mock/Routing/duplicates/DuplicateAlphaCommand.php is excluded by !tests/Mock/**
  • tests/Mock/Routing/duplicates/DuplicateBetaCommand.php is excluded by !tests/Mock/**
📒 Files selected for processing (20)
  • README.md
  • config/routing.php
  • docs/command-routing.md
  • src/DependencyInjection/Configuration.php
  • src/DependencyInjection/StixxOpenApiCommandExtension.php
  • src/Routing/CommandRouteDiscovery.php
  • src/Routing/Loader/AttributeDirectoryLoaderDecorator.php
  • src/Routing/Loader/CommandRouteClassLoader.php
  • src/Routing/Loader/RouterLoaderDecorator.php
  • src/Routing/RouteSpecificitySorter.php
  • tests/Functional/App/DiscoveryKernel.php
  • tests/Functional/BundleInitializationTest.php
  • tests/Functional/Resources/config/discovery.php
  • tests/Functional/RouteDiscoveryTest.php
  • tests/Unit/DependencyInjection/ConfigurationTest.php
  • tests/Unit/Routing/CommandRouteDiscoveryTest.php
  • tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php
  • tests/Unit/Routing/Loader/CommandRouteClassLoaderTest.php
  • tests/Unit/Routing/Loader/RouterLoaderDecoratorTest.php
  • tests/Unit/Routing/RouteSpecificitySorterTest.php

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Keep the reasoning that is not recoverable from the code — why routing.loader is
the decoration point, why $inner is typed as Loader — and drop the rest.
@stixx stixx added the bug Something isn't working label Aug 30, 2026
RouteSpecificitySorter rebuilt the collection without copying resources, and the
decorator added routes without them, so the router cache had nothing covering
the scanned command directories. In debug mode a new or edited command DTO did
not invalidate the route cache and stayed invisible until a manual cache:clear.

The previous decorator lost resources the same way, but it only ran while the
application's own AttributeDirectoryLoader was scanning src/, whose collection
carried a resource for those files. Nothing scans the command paths now, so the
resource has to come from discovery itself.

Also carry route priorities through the decorator's copy, and correct the note
on the $inner type: MicroKernelTrait calls getResolver() on this service, not
import() — RoutingConfigurator gets a RoutingPhpFileLoader, not the decorator.
@stixx

stixx commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Self-review found a blocking issue; fixed in 9207b65.

Discovered routes carried no cache resources. RouteSpecificitySorter::sort() rebuilds the collection and copied routes and priorities but not resources, and RouterLoaderDecorator::load() added routes without them. Measured: the directory loader produced 1 GlobResource, and discover() returned 0. End-to-end through a booted kernel, the router's collection had no resource covering the scanned command directory.

Since Router::warmUp() writes the cache with getRouteCollection()->getResources(), adding or editing a command DTO did not invalidate the route cache in debug mode — routes stayed stale until a manual cache:clear. Production is unaffected, but it undercuts exactly the zero-config workflow this PR is for.

Worth noting this was made worse by the PR rather than inherited: the old decorator dropped resources through the same sorter, but it only ran while the application's own AttributeDirectoryLoader was scanning src/, and that collection contributed a covering resource. Nothing else scans the command paths now.

Also in this commit:

  • Route priorities are carried through the decorator's copy loop, so the sorter's work is not silently undone.
  • Corrected the note on the $inner type. MicroKernelTrait::loadRoutes() calls getResolver() on this service — RoutingConfigurator is constructed with a RoutingPhpFileLoader, not the decorator, so the previous claim about import() was wrong.

Tests: resource preservation is now pinned at all three levels (sorter, discovery, decorator) plus the functional kernel. I verified all four fail with the fix reverted. 219 tests pass, PHPStan max and PHP-CS-Fixer clean.

Two minor points I did not change, happy to follow up if you want them:

  • Commands sharing an operationId across two command_paths entries silently shadow each other via addCollection(); ensureUniqueName() only dedups within a class.
  • import() on the decorator now looks like dead delegation — nothing appears to call it on routing.loader.

Each command class is loaded into its own collection and those are merged with
addCollection(), which overwrites by name. Two commands resolving to the same
route name therefore cost one of them its endpoint, silently — the same class of
failure this branch set out to remove.

Without an operationId the name derives from the class short name, so
Billing\CreateInvoiceCommand and Sales\CreateInvoiceCommand both resolve to
command_createinvoicecommand. Track the owning class per route name in the class
loader and throw, naming both classes. Loading the same class twice, via
discovery and an explicit import, stays valid.

Detection lives in the class loader rather than in discovery because
AttributeDirectoryLoader already merges per-class collections, so a collision
within one directory is gone before discovery sees it.

Also record why import() is delegated rather than dropped.
@stixx

stixx commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Handled both remaining findings in 97e34c1.

Route-name collisions now fail loudly. This turned out broader than the multi-path case I originally flagged. AttributeDirectoryLoader merges per-class collections with addCollection(), so two commands resolving to the same name silently cost one of them its endpoint within a single directory too — not just across command_paths entries. Since the name derives from the class short name when no operationId is set, Billing\CreateInvoiceCommand and Sales\CreateInvoiceCommand both resolve to command_createinvoicecommand.

Detection lives in CommandRouteClassLoader, which tracks the owning class per route name — the directory loader has already collapsed duplicates by the time discovery sees the collection, so that was the only hook that catches both cases. The exception names both classes and points at operationId. Loading the same class twice (discovery plus an explicit import) is not treated as a conflict, and is covered by a test.

Worth calling out as a behaviour change: an application that today has a silent collision will now fail to compile instead of quietly losing an endpoint. That is the intent — it surfaces an existing latent bug rather than introducing one — but it can turn a working build red on upgrade. Documented in docs/command-routing.md.

import() delegation kept. Nothing in the framework calls it on routing.loader, but it is public on the decorated loader, and a decorator should not shrink the surface of what it decorates. Recorded that reasoning in place of the earlier incorrect note.

221 tests, PHPStan max and PHP-CS-Fixer clean.

@stixx
stixx merged commit 957df9c into main Aug 30, 2026
6 checks passed
@stixx
stixx deleted the fix/command-route-autodiscovery branch August 30, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant